Skip to content

Release v5.21.0#1552

Merged
GianniCarlo merged 20 commits into
mainfrom
develop
Jun 10, 2026
Merged

Release v5.21.0#1552
GianniCarlo merged 20 commits into
mainfrom
develop

Conversation

@GianniCarlo

@GianniCarlo GianniCarlo commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

release notes:

Improvements

– Add multi-server support for Jellyfin and AudiobookShelf
– You can now configure the player controls to show the total remaining time of a book while having the chapter context enabled
– Improve chapter list parsing when importing books
– You can now ask Siri to play a book, or create an app shortcut to play any specific book from your library
– Add the same chapter-threshold stop we have when rewinding, to the previous-chapter button

Bugfixes

– Fix search in iOS 18
– Fix updating last played books widget after deleting a book

Special thanks to:

– klikh (sponsor)
– Canadian Organization of the Blind and DeafBlind and Matthew for their contributions to this update

If you experience any issues, please reach us at [email protected]

GianniCarlo and others added 20 commits May 18, 2026 12:01
* Update multi-server work for current develop

Brings the branch up to date with develop (Core Data v11 migration,
custom-headers PR #1513, sticky-sort, end-of-chapter fix, etc.) and
folds in a few iterations on top:

- A single \"Media Servers\" entry in the import menu in place of the
  separate Jellyfin / AudiobookShelf items. Lists every saved server
  from both integrations with type icons; \"Add Server\" picks the
  type and goes through the connection flow.

- Tap-a-server presents the per-integration library browser as a sheet
  on top of the unified list, with a leading \"Media Servers\" back
  button that closes the inner sheet and lands you back on the list.
  Earlier rework tried to push via NavigationStack but pushing a
  TabView in a NavigationStack auto-pops on iOS 26 — sheet-on-sheet
  sidesteps that entirely.

- Library picker no longer traps you when there are 2+ libraries and
  none chosen yet (Cancel is unconditional and falls back to dismissing
  the whole sheet when there's no library to dismiss to).

- Connection-form xmark closes the form instead of dismissing the
  whole integration view.

- Empty-state rows restyled to match the populated server-row layout
  so they read as tappable. Brand strings consolidated through
  ServerType.displayName, fonts unified on bpFont.

* Jellyfin: preserve customHeaders when switching/deleting connections

activateConnection and deleteConnection were calling createClient without
passing the active connection's customHeaders, so any server behind a
Cloudflare Access (or similar) proxy stopped responding after the user
switched servers or signed out of one of several — the next request went
out missing the required CF-Access-Client-* headers and was rejected
until the app was relaunched (the only path that did pass them was
reloadConnections at launch).

Centralize the client-rebuild in rebuildClient(for:) so future call sites
can't drop headers, and capture the old client before mutating state so
the fire-and-forget signOut doesn't race the new client assignment.

* Integration root: don't auto-push to add-server form on load failure

When fetchLibraries (or the equivalent ABS call) threw, the resulting
alert had a single OK button that set showConnectionForm = true. That
flow is the right one for "the user wants to sign in again" but it's
the wrong one for everything else (transient network, expired token,
custom-header proxy hiccup). Users who hit it would re-add their server
and end up with a duplicate because the byte-exact URL dedup in the
connection service didn't treat trailing-slash / scheme variants as the
same connection.

Replace the single OK with three explicit buttons — Sign In (the old
behavior, now opt-in), Retry (re-call loadLibraries), and Cancel (close
the integration sheet, return to the Media Servers list). Same fix on
both JellyfinRootView and AudiobookShelfRootView. New
"integration_retry_button" localization key seeded with English
literals across all 26 .lproj files matching the rest of the fork's
localization pattern.

* Make connect/sign-in tasks cancellable on sheet dismissal

IntegrationConnectionView's onConnect / onSignIn / onStartQuickConnect
each kicked off an unstructured Task that the view had no handle to.
If the user dismissed the sheet (swipe-to-dismiss or hitting the close
button) while a sign-in was in flight, the Task kept running and could
persist a connection to the service after the user thought they gave
up on the operation.

Store each action's Task in @State and cancel it from .onDisappear.
Catch CancellationError separately so the dismissal doesn't get
surfaced as an alert.

The View-side cancel alone isn't sufficient — Swift cooperative
cancellation only flags the Task, it doesn't synchronously interrupt
an awaiting call, and the service methods kept persisting after the
network call returned. Add try Task.checkCancellation() inside
JellyfinConnectionService.signIn(username:password:...) and
AudiobookShelfConnectionService.signIn(...) immediately after the
auth round-trip returns, so the persist step is skipped on cancel.
The matching call inside signInWithQuickConnect was already added in
the Quick-Connect cancel-race fix.

* AudiobookShelf: trim credentials and normalize the server URL

Two small input-validation gaps:

H18 — pingServer accepts a schemeless string like "abs.example.com"
verbatim because URL(string:) parses it as a relative path. The
subsequent appendingPathComponent("ping") yields "abs.example.com/ping"
which URLSession returns an opaque .unsupportedURL for, and the user
has no idea they needed to prepend https://. Normalize in the view
model before calling pingServer: trim whitespace, prepend https:// if
no scheme. Mirror the result back into the form field so the user can
see what we actually used.

H19 — Username and password were passed to ABS auth verbatim, so an
autocorrect-inserted trailing space on the username is enough to fail
otherwise-correct credentials with a 401. Trim both in
handleSignInAction.

* Canonicalize URLs before deduplicating saved connections

Foundation URL equality is byte-exact, so https://server vs
https://server/ vs https://server:443 vs https://Server were treated as
four different connections by the deduplication in
JellyfinConnectionService and AudiobookShelfConnectionService. Users
hitting the C2 error alert in either integration would commonly re-add
their server slightly differently (often with a trailing slash) and
end up with two saved entries, one of which was broken.

Add URL.canonicalDedupKey to BookPlayerKit's URL extension: lowercase
scheme/host, drop default ports (80/443), strip the trailing slash on
non-root paths, and drop userinfo/query/fragment. Both services'
removeAll-then-append dedup blocks now compare canonical keys.

* Real 401 re-auth flow (C2 architectural)

Token-expiry / mid-session-unauthorized had no first-class handling —
401/403 propagated as a generic load failure, the alert offered a
single OK that pushed the user into the add-server form, and users
who took it ended up with duplicate connections (the C2-minimum fix
just rearranged the alert buttons to stop the worst case).

Distinguish session-expired from generic failures end to end:

- New IntegrationError.sessionExpired(serverName:) carries the offending
  server name so the alert message is specific ("Your session for
  Library Server expired. Sign in again to continue.").
- Jellyfin: wrap the api-client `send<T>` to catch
  APIError.unacceptableStatusCode(401|403) and throw .sessionExpired —
  only when there's actually a saved connection (so pre-sign-in probes
  inside `findServer` aren't mis-classified).
- ABS: centralize HTTP status validation in
  validateAuthenticatedResponse(_:) and use it across every
  authenticated data-fetch site (fetchLibraries, fetchItems,
  fetchItemDetails, search, …). Same connection-presence guard. The
  signIn path keeps its existing 401 → URLError(.userAuthenticationRequired)
  mapping ("wrong password" is a different UX than "your session expired").
- Both RootView alerts (JellyfinRootView, AudiobookShelfRootView)
  special-case `IntegrationError.isSessionExpired`: show Sign In +
  Cancel only, skip the Retry button that's meaningless when the
  token's revoked.
- Both signIn implementations now preserve the existing connection's
  id and selectedLibraryId when re-authing on the same
  (canonical-url, userID) pair — so when the user signs in again
  they land back on the same library context they had before,
  not a fresh record. This is the half of the fix that makes "Sign In
  again" actually transparent UX rather than "you lost your saved
  library choice".

New localization key `integration_error_session_expired` seeded with
English literals across all 26 .lproj files.

* Address feedback

* Replace settings options

* Address more feedback

* Update localizations

* Address feedback

* change icon

---------

Co-authored-by: Gianni Carlo <[email protected]>
…ss (#1524)

* fix: don't crash when audio session activation fails

PlayerManager.play(autoPlayed:) called fatalError() if either
setCategory or setActive(true) threw. Background auto-play (CarPlay,
headphones, lock-screen, scheduled) routinely hits cases where another
process holds the audio session (phone call, navigation, voice memo) or
the OS denies activation, and the app would hard-crash with
EXC_BREAKPOINT in libswiftCore from the fatalError trap.

Replace with a log-and-return: NSLog the underlying error, clear the
queued-playback flag, bail out of this play attempt. A missed auto-play
is strictly better than the app dying in the background.

Crash signature: TestFlight feedback build 5.19.0 (20260428053421),
iPhone 15 Pro / iOS 26.5, role=Background, in PlayerManager.swift:876.

* fix: recover when audio session is contested instead of bailing silently

Follow-up to e66eb62. Make the recovery match how iOS audio apps
normally behave: bind the interruption observer before attempting
activation, and on failure keep playbackQueued = true so the existing
handleAudioInterruptions path will retry play(autoPlayed:) when iOS
reports the session is free again (call ended, navigation done, voice
memo dismissed). Previously the catch left playbackQueued = nil, so a
contested auto-play was a dead-end until the user manually pressed play.

* fix: gate audio-session recovery to beta and retry with backoff

Addresses review feedback on #1524.

The previous recovery relied solely on AVAudioSession's `.ended`
interrupt notification to retry play. That notification only fires
for sessions that were already active and got interrupted — it does
not fire when `setActive(true)` itself throws, which is precisely
the unrecoverable state described in review (force-quit was the
only fix). Without an alternative trigger the UI would sit in the
queued/loading state indefinitely.

Changes:
- Gate the non-fatal path to beta (TestFlight / DEBUG) builds.
  Release builds keep the original `fatalError` so the App Store
  version isn't masked while the recovery is unproven.
- Add `scheduleAudioSessionRecovery`: bounded retry at 1s/3s/8s
  using the deactivate-then-reactivate pattern that's known to
  unstick the contested-session state. On success, resume playback;
  on exhaustion, clear `playbackQueued` and surface an alert with
  the same actionable hint the crash provided ("close and reopen").
- Cancel any pending retry from `play`, `pause`, and `stopPlayback`
  so user-initiated state changes aren't clobbered by a late retry.

* fix: use AppEnvironment.isTestFlight for the recovery gate

Replaces a duplicated TestFlight check with the existing
AppEnvironment.isTestFlight helper used elsewhere in the project
(TipJar, AccountService, PurchasesManager). Behavior change: DEBUG
builds no longer take the recovery path — they keep the original
fatalError so the failure is loud during development. Only true
TestFlight installs run the bounded retry, which matches the
"just to see if we get reports" intent of the gate.

* simplify report

* cleanup

---------

Co-authored-by: Gianni Carlo <[email protected]>
* Fix searching on iOS 18

* Address feedback
* Fix updating last played widget after delete

* address feedback

* address feedback
* Add support for total remaining time in chapter context

* address feedback

* address feedback 2
Add playing specific book with app intents
Add chapter threshold logic for prev chapter button
@GianniCarlo GianniCarlo merged commit be801d0 into main Jun 10, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants